非同期呼び出しAWS Lambdaのエラーハンドリング機能を整理してみた(宛先指定・経過時間・試行回数・DLQ)
Pub/Subのようなイベント駆動型アーキテクチャーではパブリッシャーとサブスクライバーは非同期に独立して動作します。
パブリッシャーが送信したメッセージをどう処理するかはサブスクライバー次第であり、信頼性の高いメッセージングシステムを構築・運用するためには、サブスクライバーをより堅牢に実装する必要があります。
AWS の非同期メッセージ処理でよく利用される AWS Lambda に、異常系フローをいい感じに処理する機能が最近(といっても数ヶ月前ですが)になって複数リリースされたので、まとめてご紹介します。
- 最大再試行回数
- Lambda 関数が実行後にエラー(function error)を返した時は再試行します。
- この回数を 0~2 回にカスタマイズできます。
- 最大イベント経過時間
- AWS Lambda の非同期処理は、内部的にキューイングシステムを利用しています。
- スロットリングなどにより Lambda 関数の呼び出しが失敗しても(invocation error)、指定時間を超過するまで関数呼び出しをリトライします。
- この期間を 1分〜6時間にカスタマイズできます。
- 宛先指定
- Lambda 関数の実行ステータス(成功または失敗)によって、別の処理(Lambda/SNS/SQS/EventBridge)を呼び出せまるようになりました。
- エラーは invocation error と function error の両方が対象です。
- 失敗時にのみ利用できた DLQ の機能強化版です。
おすすめ構成
先に結論から。
非同期Lambdaの失敗フローは宛先指定機能を利用してリカバリー処理にルーティングするのがおすすめです。
宛先指定機能は従来の DLQ よりも詳細な情報を取得でき、Lambda/SNS/SQS/EventBridgeなど様々なターゲットに対応しています。
成功系も失敗系もルーティング設定をこの宛先指定機能に集約できます。
以下、各機能を確認します。
宛先指定
Lambda 関数の実行ステータス(成功または失敗)ごとに、別の処理(Lambda/SNS/SQS/EventBridge)を呼び出せまるようになりました。
以下の特徴があります。
- 成功と失敗の両方のステータスに対応している
- ユーザーは成功・失敗の分岐やつなぎ込みを実装しなくてよい
- 複数のターゲット(Lambda/SNS/SQS/EventBridge)に対応している
類似機能である Dead-letter Queue(DLQ) と比較します。
機能 | AWS Lambda DLQ | AWS Lambda Destination |
---|---|---|
対応ステータス | 失敗 | 成功・失敗 |
ターゲット | SNS/SQS | SNS/SQS/Lambda/EventBridge |
ターゲットの指定数 | 1 | 1 |
ターゲットへのメッセージ | 簡易 | バージョン、タイムスタンプ、リクエストコンテキスト、リクエストペイロード、レスポンスコンテキスト、レスポンスペイロード |
関数を用意
入力イベントをそのまま返すだけの Lambda 関数を用意します。
def lambda_handler(event, context): return event
設定方法
Lambda 関数の宛先を選択し、成功・失敗のステータスに応じてターゲットを選択するだけです。
成功・失敗のステータスごとに指定できるターゲットは一つだけです。
ターゲットを SNS か EventBridge にしておくと、将来の Fan Out がかんたんになります。
今回は 成功・失敗それぞれに対して専用の SNS トピックを用意し、JSON-Email で受け取るようにしました。
実行例
Lambda 関数を非同期に呼び出します。
$ aws lambda invoke --function-name test \ --invocation-type Event \ --payload '{"foo":1}' response.json { "StatusCode": 202 }
通知例
{ "Type" : "Notification", "MessageId" : "XXX", "TopicArn" : "arn:aws:sns:eu-central-1:1234:success", "Message" : "...", "Timestamp" : "2020-03-28T16:13:35.540Z", "SignatureVersion" : "1", "Signature" : "XXX==", "SigningCertURL" : "https://sns.eu-central-1.amazonaws.com/SimpleNotificationService-XXX.pem", "UnsubscribeURL" : "https://sns.eu-central-1.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=XXX" }
メインの Message 属性を抜粋します。
{ "version": "1.0", "timestamp": "2020-03-28T16:13:35.490Z", "requestContext": { "requestId": "XXX", "functionArn": "arn:aws:lambda:eu-central-1:1234:function:test:$LATEST", "condition": "Success", "approximateInvokeCount": 1 }, "requestPayload": {"foo": 1}, "responseContext": { "statusCode": 200, "executedVersion": "$LATEST" }, "responsePayload": {"foo": 1} }
- 7行目の
condition
から 成功(Success)したことがわかります。 - リクエストコンテキスト(
requestContext
)、リクエストペイロード(requestPayload
)、レスポンスペイロード(responsePayload
)が含まれています。
以下、比較のために、このLambda 関数の Dead-letter Queue にも専用の SNS トピックを関連付け、宛先 failure と DLQ の メッセージの違いを比較します。
最大再試行回数
Lambda 関数の実行が例外やタイムアウトで失敗(「関数エラー」)した時の再試行回数を 0~2 回でカスタマイズします。
1回目の再試行はエラーから1分後に、2回目の再試行はエラーから2分後に行います。
※ 図は公式ドキュメントから
「最大再試行回数」は権限・スロットリングのような「呼び出しエラー」時の再試行をカスタマイズするものではありません。 そちらは「最大イベント経過時間」で制御します。
関数を用意
必ず失敗する Lambda 関数を用意します。
def lambda_handler(event, context): x # undefined
設定方法
Lambda 関数の「asynchronous configuration」の 「Retry attempts」から回数を 0 〜 2 の範囲で指定します。
実行例
Lambda 関数を非同期に呼び出します。
$ aws lambda invoke --function-name test \ --invocation-type Event \ response.json { "StatusCode": 202 }
通知例
Destination
{ "version": "1.0", "timestamp": "2020-03-28T16:23:31.695Z", "requestContext": { "requestId": "XXX", "functionArn": "arn:aws:lambda:eu-central-1:1234:function:test:$LATEST", "condition": "RetriesExhausted", "approximateInvokeCount": 3 }, "requestPayload": {}, "responseContext": { "statusCode": 200, "executedVersion": "$LATEST", "functionError": "Unhandled" }, "responsePayload": { "errorMessage": "name 'x' is not defined", "errorType": "NameError", "stackTrace": [ " File \"/var/task/lambda_function.py\", line 3, in lambda_handler\n x\n" ] } }
condition:RetriesExhausted
からリトライの上限に引っかかったことがわかります。approximateInvokeCount:3
から最大再試行回数程度までリトライされたことがわかります。responsePayload
にトレースバックが出力されています。
DLQ
続いて DLQ のメッセージを確認します。
"MessageAttributes" : { "RequestID" : {"Type":"String","Value":"XXX"}, "ErrorCode" : {"Type":"Number","Value":"200"}, "ErrorMessage" : {"Type":"String","Value":"name 'x' is not defined"} }
MessageAttributes に簡易的なトレースバックが出力されています。 ただし、Lambda 関数のなどコンテキスト情報が存在しません。 RequestID から呼び出し元をたどる必要があります。
最大イベント経過時間
スロットリングなどにより Lambda 関数の呼び出しが失敗した時の再試行期間を1分〜6時間でカスタマイズします。
再試行間隔は、最初の試行後 1 秒から最大 5 分まで指数関数的に増加します。ただし、キューがバックアップされると長くなる可能性があります。
最大イベント経過時間は例外やタイムアウトのような「関数エラー」時の再試行をカスタマイズするものではありません。 そちらは「最大再試行回数」で制御します。
関数を用意
実行に1分かかる Lambda 関数を用意します。
import time def lambda_handler(event, context): time.sleep(60)
設定方法
Lambda 関数の「asynchronous configuration」の 「Maximum age of event」を 1分 〜 6時間 の範囲で指定します。
デフォルトは6時間です。
今回は検証しやすいように90秒に指定します。
さらに、同時実行数の予約を1にし、並列で呼び出せば、スロットリングが発生するようにします。
実行例
Lambda 関数を非同期に 3回呼び出して、意図的にスロットリングを発生させます。
$ aws lambda invoke --function-name test \ --invocation-type Event \ response.json { "StatusCode": 202 } $ aws lambda invoke --function-name test \ --invocation-type Event \ response.json { "StatusCode": 202 } $ aws lambda invoke --function-name test \ --invocation-type Event \ response.json { "StatusCode": 202 }
X-Ray では非同期イベントキューで実行をまっている時間が dwell time として計上されます。
X-Ray から この dwell time と 最大イベント経過時間 の関係を確認します。
3呼び出しのうち、1つはスロットリングに引っかからずに実行され、1分後に実行が完了します。
1つは約1分間スロットリングされたあとで実行され、さらに1分後に実行が完了します。
最後の一つはスロットリングされ続け、90秒の最大イベント経過時間を超過し、異常終了します。
通知例
最大イベント経過時間に引っかかったイベントだけが failure/DLQ にルーティングされます。
Destination
{ "version": "1.0", "timestamp": "...", "requestContext": { "requestId": "XXX", "functionArn": "arn:aws:lambda:eu-central-1:1234:function:test:$LATEST", "condition": "EventAgeExceeded", "approximateInvokeCount": 0 }, "requestPayload": {}, "responseContext": { "statusCode": 429 } }
condition:EventAgeExceeded
から経過時間に引っかかったことがわかります。- スロットリングされ続けたので
"approximateInvokeCount": 0
となっています。
DLQ
"MessageAttributes" : { "RequestID" : {"Type":"String","Value":"XXX"}, "ErrorCode" : {"Type":"Number","Value":"429"}, "ErrorMessage" : {"Type":"String","Value":"Rate Exceeded."} }
経過時間を超過したときは、ErrorMessage : {"Type":"String","Value":"Rate Exceeded."}
となるようです。
最大再試行回数よりも最大イベント経過時間が優先される
最大再試行回数だけリトライするよりも先に、最大イベント経過時間を超過したときの振る舞いを確認すると、最大再試行回数だけリトライするよりも最大イベント経過時間がもとで関数呼び出しは異常終了していました。 ただし、通知メッセージは再試行回数を超過したときのものでした。
関数を用意
呼び出しから1分後に例外が発生する関数を用意します。
import time def lambda_handler(event, context): time.sleep(60) x # undefined
- 最大再試行回数 : 2
- 最大イベント経過時間 : 90秒
で実行してみます。
この関数は呼び出して1分後に例外が発生し、1分後にリトライを試みます。 ただし、リトライ待機中にイベント経過時間を超過するため、一度もリトライは行われません。
通知例
Destination
{ "version": "1.0", "timestamp": "...", "requestContext": { "requestId": "XXX", "functionArn": "arn:aws:lambda:eu-central-1:1234:function:test:$LATEST", "condition": "RetriesExhausted", "approximateInvokeCount": 1 }, "requestPayload": {}, "responseContext": { "statusCode": 200, "executedVersion": "$LATEST", "functionError": "Unhandled" }, "responsePayload": { "errorMessage": "name 'x' is not defined", "errorType": "NameError", "stackTrace": [ " File \"/var/task/lambda_function.py\", line 4, in lambda_handler\n x\n" ] } }
condition:RetriesExhausted
からリトライの上限に引っかかったことがわかります。approximateInvokeCount:1
から 再試行回数までリトライしていないことがわかります。
DLQ
続いて DLQ のメッセージを確認します。
"MessageAttributes" : { "RequestID" : {"Type":"String","Value":"XXX"}, "ErrorCode" : {"Type":"Number","Value":"200"}, "ErrorMessage" : {"Type":"String","Value":"name 'x' is not defined"} }
関数エラー時のメッセージです。
まとめ
非同期なLambdaの呼び出しに対して、最大再試行回数、最大イベント経過時間、実行ステータスごとの宛先を指定できるようになりました。
- 非同期Lambdaの実行ステータス(成功・失敗)によって後続処理を指定できるようになった
- 失敗時の遷移先にSNSまたはEventBridgeを指定しておくと、異常系処理をスッキリかける
これだけ覚えておけば大丈夫です。